package org.yacon;

import org.w3c.dom.*;

import javax.xml.parsers.DocumentBuilderFactory;
import java.io.*;
import java.lang.reflect.Constructor;
import java.net.URL;
import java.util.*;
import java.lang.reflect.Method;
import java.util.logging.Level;
import java.util.logging.Logger;

import static org.yacon.XMLElementsConsts.*;

/**
 * Performs XML Commands configuration parsing to Java Object representation
 *
 * @author Evgeny Vetrov
 */
public class ConsoleConfiguration {
    private static final String JAXP_SCHEMA_LANGUAGE =
            "http://java.sun.com/xml/jaxp/properties/schemaLanguage";

    private static final String W3C_XML_SCHEMA =
            "http://www.w3.org/2001/XMLSchema";

    private static final String schemaSource = "org/yacon/command.xsd";
    private static final String JAXP_SCHEMA_SOURCE =
            "http://java.sun.com/xml/jaxp/properties/schemaSource";

    private static final Logger log = Logger.getLogger(ConsoleConfiguration.class.getName());

    private final Map<String, ConsoleCommand> commandsMap;
    private final Map<String, Program> programsMap;


    /**
     * Creates ConsoleConfiguration based on XML configuration document passed as InputStream
     *
     * @param configContentStream XML Commands configuration document to be parsed
     */
    private ConsoleConfiguration(InputStream configContentStream) {
        URL resource = ConsoleEngine.class.getClassLoader().getResource(schemaSource);
        if(resource == null)
            throw new NullPointerException("can't find command.xsd resource");
        InputStream schemaStream = null;
        try {
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            factory.setIgnoringElementContentWhitespace(true);
            factory.setNamespaceAware(true);
            factory.setAttribute(JAXP_SCHEMA_LANGUAGE, W3C_XML_SCHEMA);

            schemaStream = resource.openStream();
            factory.setAttribute(JAXP_SCHEMA_SOURCE, schemaStream);
            factory.setValidating(true);
            factory.setIgnoringElementContentWhitespace(true);
            factory.setIgnoringComments(true);
            Document config = factory.newDocumentBuilder().parse(configContentStream);
            if (config == null)
                throw new NullPointerException("Config can't be null");
            Element root = config.getDocumentElement();
            Map<String, ConsoleCommand> commandsMap = new HashMap<String, ConsoleCommand>();
            Map<String, Program> programsMap = new HashMap<String, Program>();
            parseCommands(root.getChildNodes(), commandsMap);
            parsePrograms(commandsMap, programsMap, root.getElementsByTagName(ELEMENT_PROGRAM));
            this.commandsMap = Collections.unmodifiableMap(commandsMap);
            this.programsMap = Collections.unmodifiableMap(programsMap);
        } catch (Exception e) {
            if (e instanceof RuntimeException)
                throw (RuntimeException) e;
            throw new RuntimeException(e);
        } finally {
            if (schemaStream != null)
                try {
                    schemaStream.close();
                } catch (IOException e) {
                    log.log(Level.WARNING, e.getMessage(), e);
                }
        }
    }

    /**
     * Creates ConsoleConfiguration based on XML configuration from specified file
     *
     * @param configPath path to file with XML configuration
     * @return created ConsoleConfiguration instance
     */
    public static ConsoleConfiguration create(String configPath) {
        ConsoleConfiguration configuration;
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(configPath);
            configuration = create(fis);
        } catch (FileNotFoundException e) {
            throw new RuntimeException(e);
        } finally {
            if (fis != null)
                try {
                    fis.close();
                } catch (IOException e) {
                    log.log(Level.SEVERE, e.getMessage(), e);
                }
        }
        return configuration;
    }

    /**
     * Creates ConsoleConfiguration based on XML configuration from specified InputStream.
     * Passed InputStream is not closed
     *
     * @param configContentStream XML configuration
     * @return created ConsoleConfiguration instance
     */
    public static ConsoleConfiguration create(InputStream configContentStream) {
        return new ConsoleConfiguration(configContentStream);
    }

    private void parsePrograms(Map<String, ConsoleCommand> commandsMap, Map<String, Program> programsMap,
                               NodeList programElements) {
        for (int i = 0, k = programElements.getLength(); i < k; i++) {
            Element programElement = (Element) programElements.item(i);
            NamedNodeMap attrs = programElement.getAttributes();
            Set<String> names = getCmdNames(attrs);
            assertProgramNamesUnique(commandsMap, programsMap, names);
            String description = getStringAttrValue(attrs, ATTR_DESCRIPTION);
            CommandHandler pipe = createPipeObject(getStringAttrValue(attrs, ATTR_PIPE));
            NodeList initElements = programElement.getElementsByTagName(ELEMENT_INIT);
            NodeList destroyElements = programElement.getElementsByTagName(ELEMENT_DESTROY);
            ConsoleCommand initCommand = null;
            ConsoleCommand destroyCommand;
            if (initElements.getLength() > 0) {
                initCommand = createInitDestroyCommand(names, initElements);
            }
            if (destroyElements.getLength() > 0) {
                Element destroyElement = (Element) destroyElements.item(0);
                Set<String> destroyNames = getCmdNames(destroyElement.getAttributes());
                destroyCommand = createInitDestroyCommand(destroyNames, destroyElements);
            } else {
                Method method = getMethod("org.yacon.ConsoleEngine$ExitCommand.exit", Collections.singleton("exit"));
                destroyCommand = ConsoleCommand.Builder.create(Collections.singleton("exit"), method, null).build();
            }
            Element cmdsElement = (Element) programElement.getElementsByTagName(ELEMENT_CMDS).item(0);
            Map<String, ConsoleCommand> programCommands = new HashMap<String, ConsoleCommand>();
            if (destroyCommand != null)
                for (String name : destroyCommand.getNames())
                    programCommands.put(name, destroyCommand);
            if (cmdsElement != null)
                parseCommands(cmdsElement.getChildNodes(), programCommands);
            else if (pipe == null)
                throw new ParserException("'" + ELEMENT_PROGRAM + "' should have '" + ELEMENT_CMDS + "' element or '"
                        + ATTR_PIPE + "' specified or both");
            Program program = new Program(programCommands, initCommand, destroyCommand, names, description, pipe);
            for (String name : names)
                programsMap.put(name, program);
        }
    }

    private CommandHandler createPipeObject(String className) {
        if (className == null)
            return null;
        className = className.trim();
        if ("".equals(className))
            throw new ParserException("'" + ATTR_PIPE + "' attribute should have valid name of the class implementing " +
                    CommandHandler.class.getName() + " interface");
        try {
            Class clazz = Class.forName(className);
            Constructor constructor = clazz.getDeclaredConstructor();
            constructor.setAccessible(true);
            return (CommandHandler) constructor.newInstance();
        } catch (ClassNotFoundException e) {
            throw new ParserException("class '" + className + "' specified for '"
                    + ATTR_PIPE + "' attribute is not found", e);
        } catch (NoSuchMethodException e) {
            throw new ParserException("class '" + className + "' specified for '"
                    + ATTR_PIPE + "' attribute should have default constructor", e);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private ConsoleCommand createInitDestroyCommand(Set<String> names, NodeList cmdElements) {
        Element initElement = (Element) cmdElements.item(0);
        NamedNodeMap attributes = initElement.getAttributes();
        Method method = retrieveMethod(attributes, names);
        Map<String, TypeConverter> converters = new HashMap<String, TypeConverter>();
        Set<CommandParameter> cmdParams = parseCommandParameters(initElement.getChildNodes(), converters);
        String description = getStringAttrValue(attributes, ATTR_DESCRIPTION);
        return ConsoleCommand.Builder.create(names, method, cmdParams)
                .setConverters(converters)
                .setDescription(description)
                .build();
    }

    private void assertProgramNamesUnique(Map<String, ConsoleCommand> commandsMap, Map<String, Program> programsMap,
                                          Set<String> names) {
        for (String name : names)
            if (commandsMap.containsKey(name))
                throw new ParserException("There are cmd and program with same name=" + name);
            else if (programsMap.containsKey(name))
                throw new ParserException("There are several programs with same name=" + name);
    }

    private void parseCommands(NodeList cmdElements, Map<String, ConsoleCommand> commandsMap) {
        Set<String> cmdsNames = new HashSet<String>();
        for (int i = 0, k = cmdElements.getLength(); i < k; i++) {
            Node node = cmdElements.item(i);
            if (Node.ELEMENT_NODE != node.getNodeType() || !ELEMENT_CMD.equals(((Element) node).getTagName()))
                continue;
            NamedNodeMap attrs = node.getAttributes();
            Set<String> names = getCmdNames(attrs);
            for (String name : names)
                if (!cmdsNames.add(name))
                    throw new ParserException("There are several cmds with same name");
            Method method = retrieveMethod(attrs, names);
            Map<String, TypeConverter> converters = new HashMap<String, TypeConverter>();
            Set<CommandParameter> cmdParams = parseCommandParameters(node.getChildNodes(), converters);
            ConsoleCommand.Builder builder = ConsoleCommand.Builder.create(
                    names, method, cmdParams).setConverters(converters);
            Attr descriptionAttr = (Attr) attrs.getNamedItem(ATTR_DESCRIPTION);
            if (descriptionAttr != null)
                builder.setDescription(descriptionAttr.getValue());
            ConsoleCommand command = builder.build();
            for (String name : names)
                commandsMap.put(name, command);
        }
    }

    private Method retrieveMethod(NamedNodeMap attrs, Set<String> names) {
        Attr methodNameAttr = (Attr) attrs.getNamedItem(ATTR_METHOD);
        String methodName = methodNameAttr.getValue();
        if (methodName == null || methodName.length() == 0)
            throw new ParserException("Attribute " + ATTR_METHOD + " is empty for cmd name \"" + names + "\"");
        return getMethod(methodName, names);
    }

    private Set<String> getCmdNames(NamedNodeMap attrs) {
        Set<String> names = parseCommaString2Set(((Attr) attrs.getNamedItem(ATTR_NAME)).getValue());
        if (names.isEmpty())
            throw new ParserException("Cmd element hasn't got name");
        return names;
    }

    private Method getMethod(String methodNameStr, Set<String> cmdNames) {
        int i = methodNameStr.lastIndexOf(".");
        if (i < 0 || i >= methodNameStr.length())
            throw new ParserException("Method name is incorrect for cmd name: \"" + cmdNames +
                    "\". It should be of format <full class name>.<method name>");
        String className = methodNameStr.substring(0, i);
        Class clazz;
        try {
            clazz = Class.forName(className);
        } catch (ClassNotFoundException e) {
            throw new ParserException("Class name you specified for cmd \"" + cmdNames + "\" " +
                    "in method name is not found");
        }
        String methodName = methodNameStr.substring(i + 1);
        Method result;
        try {
            result = clazz.getMethod(methodName, Map.class);
            result.setAccessible(true);
        } catch (NoSuchMethodException e) {
            throw new ParserException("Method name you specified for cmd \"" + cmdNames + "\" " +
                    "in method name isn't correct. Or signature of method you specified is not correct, " +
                    "it should be with only one argument of type java.util.Map");
        }
        return result;
    }

    private Set<String> parseCommaString2Set(String str) {
        String[] sa = str.split(",");
        Set<String> result = new HashSet<String>();
        for (String s : sa)
            result.add(s.trim());
        return result;
    }

    private Set<CommandParameter> parseCommandParameters(NodeList paramsNodes, Map<String, TypeConverter> converters) {
        Set<CommandParameter> result = new HashSet<CommandParameter>();
        for (int i = 0; i < paramsNodes.getLength(); i++) {
            Node paramNode = paramsNodes.item(i);
            if (paramNode.getNodeType() != Node.ELEMENT_NODE)
                continue;
            NamedNodeMap attrsMap = paramNode.getAttributes();
            String paramName = ((Attr) attrsMap.getNamedItem(ATTR_NAME)).getValue();
            if (paramName == null || paramName.length() == 0)
                continue;
            String description = getStringAttrValue(attrsMap, ATTR_DESCRIPTION);
            String requiredStr = getStringAttrValue(attrsMap, ATTR_REQUIRED);
            boolean required = requiredStr != null && Boolean.parseBoolean(requiredStr);
            TypeConverter converter = parserConverterNode(attrsMap);
            if (converter != null)
                converters.put(paramName, converter);
            Map<String, Set<CommandParameter>> params = parseParamNodes(converters, paramNode.getChildNodes());
            result.add(CommandParameter.Builder.create(paramName, params)
                    .setDescription(description)
                    .setRequired(required)
                    .setPattern(getStringAttrValue(attrsMap, PATTERN))
                    .setType(getStringAttrValue(attrsMap, TYPE_NAME))
                    .build());
        }
        return result;
    }

    private Map<String, Set<CommandParameter>> parseParamNodes(Map<String, TypeConverter> converters,
                                                               NodeList children) {
        Map<String, Set<CommandParameter>> params = new LinkedHashMap<String, Set<CommandParameter>>();
        Set<CommandParameter> nonConditionalParams = new HashSet<CommandParameter>();
        boolean switchFlag = false;
        for (int j = 0; children != null && j < children.getLength(); j++) {
            Node child = children.item(j);
            String nodeName = child.getNodeName();
            if (ELEMENT_PARAM.equals(nodeName)) {
                nonConditionalParams.addAll(parseCommandParameters(new SingleNodeList(child), converters));
                params.put(null, nonConditionalParams);
            } else if (switchFlag && ELEMENT_SWITCH.equals(nodeName)) {
                throw new ParserException("There can't be more then one \"switch\" element inside \"param\" element");
            } else if (ELEMENT_SWITCH.equals(nodeName)) {
                switchFlag = true;
                NodeList caseList = child.getChildNodes();
                for (int k = 0; k < caseList.getLength(); k++) {
                    Node eCase = caseList.item(k);
                    if (eCase.getNodeType() != Node.ELEMENT_NODE)
                        continue;
                    String value = ELEMENT_DEFAULT.equals(eCase.getNodeName()) ? "" :
                            ((Attr) eCase.getAttributes().getNamedItem(ATTR_VALUE)).getValue();
                    params.put(value, parseCommandParameters(eCase.getChildNodes(), converters));
                }
            }
        }
        return params;
    }

    @SuppressWarnings("unchecked")
    private TypeConverter parserConverterNode(NamedNodeMap attrsMap) {
        String typeName = getStringAttrValue(attrsMap, TYPE_NAME);
        String converterClass = getStringAttrValue(attrsMap, CONVERTER_CLASS);
        String pattern = getStringAttrValue(attrsMap, PATTERN);
        if (typeName != null && converterClass != null)
            throw new ParserException("'" + TYPE_NAME + "' and '" + CONVERTER_CLASS + "' attributes can't" +
                    " be specified simultaneously");
        if (PATTERN.equals(typeName) && (pattern == null || pattern.isEmpty()))
            throw new ParserException("" + PATTERN + "' attribute should be specified");
        if (typeName == null && converterClass == null && pattern != null) {
            if (pattern.isEmpty())
                throw new ParserException("'" + PATTERN + "' attribute can't be empty");
            typeName = PATTERN;
        }
        Map<String, String> attrValues = nodeMapToAttrsMap(attrsMap);
        if (typeName == null || typeName.length() == 0) {
            if (converterClass != null && converterClass.length() != 0) {
                try {
                    Class clazz = Class.forName(converterClass);
                    return TypeConverterHelper.getConverter(clazz, attrValues);
                } catch (ClassNotFoundException e) {
                    throw new RuntimeException(e);
                }
            }
        } else {
            return TypeConverterHelper.getConverter(typeName, attrValues);
        }
        return null;
    }

    private Map<String, String> nodeMapToAttrsMap(NamedNodeMap attrsMap) {
        Map<String, String> attrValues = new HashMap<String, String>();
        for (int i = 0, k = attrsMap.getLength(); i < k; i++) {
            Node attrNode = attrsMap.item(i);
            if (Node.ATTRIBUTE_NODE == attrNode.getNodeType()) {
                Attr attr = (Attr) attrNode;
                attrValues.put(attr.getName(), attr.getValue());
            }
        }
        return Collections.unmodifiableMap(attrValues);
    }

    private String getStringAttrValue(NamedNodeMap attrsMap, String attrName) {
        Attr attr = (Attr) attrsMap.getNamedItem(attrName);
        return attr == null ? null : attr.getValue();
    }

    /**
     * Seeks for command by it's name from specified XML configuration
     *
     * @param name of the command to be returned
     * @return Command with specified name, if no command exists for specified name <code>null</code> is returned
     */
    ConsoleCommand getCommand(String name) {
        return commandsMap.get(name);
    }

    /**
     * Seeks for program by it's name from specified XML configuration
     *
     * @param name of the program to be returned
     * @return Program with specified name, if no program exists for specified name <code>null</code> is returned
     */
    Program getProgram(String name) {
        return programsMap.get(name);
    }

    /**
     * @return All existing commands from specified XML configuration
     */
    Collection<ConsoleCommand> getCommands() {
        return commandsMap.values();
    }

    /**
     * @return All existing programs from specified XML configuration
     */
    Collection<Program> getPrograms() {
        return programsMap.values();
    }

    private class SingleNodeList implements NodeList {
        private final Node node;

        public SingleNodeList(Node node) {
            this.node = node;
        }

        public Node item(int index) {
            return node;
        }

        public int getLength() {
            return 1;
        }
    }
}